查看原文
其他

Android 音视频开发-- 使用AudioRecord 录制PCM(录音),AudioTrack播放音频

技术最TOP 2022-08-26

今天要完成的功能如下;

  • 使用AudioRecord 进行录音
  • 生成 wav 格式的音频,并进行播放
  • 使用 AudioTrack 播放 pcm 格式音频 (Stream 和 static 模式)

由于声音不好上动图,只能来一张静图了,具体代码看工程:https://github.com/LillteZheng/VideoDemo

一、基础知识

首先,我们先要了解声音是怎么被保存的起来的。在我们的世界中,声音是连续不断的,是一种模拟信号,那如何把声音保存起来呢?计算机能识别的就是二进制,所以,对声音这种模拟信号,采用数字化,即转换成数字信号,就能保存了。

从上面知道,声音是一种波,有自己的振幅和频率,如果要保存声音,就要保存各个时间点上的振幅;而数字信号并不能保存所有时间点的振幅,事实上,并不需要保存连续的信号,就可以还原到人耳可接受的声音;根据奈奎斯特定律:为了不失真地恢复模拟信号,采样频率应该不小于模拟信号频谱中最高频率的2倍。

音频数据的承载方式,最常用的就是 脉冲编码调制,即 PCM

根据上面的分析,PCM 的采集步骤可以以下步骤:

模拟信号 -> 采样 -> 量化 -> 编码 -> 数字信号

1.1 采样率

上面提到,采样率要大于原声波频率的2倍,人耳能听到的最高频率为 20khz,所以,为了满足人耳的听觉要求,采样率至少为40khz,通常就是为 44.1khz,更高则是 48 khz。一般我们都采用 44.1khz 即可达到无损音质。

1.2 采样位数

上面说到模拟信号是连续的样本值,而数字信号一般是不连续的,所以模拟信号量化,只能取一个近似的整数值,为了记录这些振幅值,采样器会采用一个固定的位数来记录这些振幅值,通常有 8 位,16位,32位。

位数越大,记录的值越准确,还原度越高。

最后就是编码了,数字信号由0,1组成的,因此,需要将振幅转换成一系列 0和1进行存储,也就是编码,最后得到的数据就是数字信号:一串0和1组成的数据:

1.3 声道数

指支持能 不同发声(注意是不同声音) 的音响的个数。

  • 单声道:一个声道
  • 双声道:2个声道
  • 立体声:2个声道
  • 立体声(4声道):4个声道

二. 使用 AudioRecord 录音

上面了解音频的基础知识后,我们接着使用 AudioRecord 来录制原始数据,即 PCM 数据;

当手机的硬件录音之后,AudioRecord 可以从该硬件提取音频资源;读取的方法可以使用 read()方法来实现。那么我们现在开始,首先,先申请好权限:

<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
 <uses-permission android:name="android.permission.RECORD_AUDIO" />

在 button 的down 事件开始录音,在up 的时候停止录音:

switch (event.getAction()) {
   case MotionEvent.ACTION_DOWN:
       //开始录制
       startRecord();
       break;
   case MotionEvent.ACTION_UP:
   case MotionEvent.ACTION_CANCEL:
       if (mAudioThread != null) {
           mAudioThread.done();
       }
       break;

#startRecord()
private void startRecord() {
     //如果存在,先停止线程
     if (mAudioThread != null) {
         mAudioThread.done();
         mAudioThread = null;
     }
     //开启线程录制
     mAudioThread = new AudioThread();
     mAudioThread.start();
 }

接着初始化 AudioRecord:

/**
 * 获取最小 buffer 大小,即一帧的buffer
 * 采样率为 44100,双声道,采样位数为 16bit
 */

minBufferSize = AudioRecord.getMinBufferSize(AUDIO_RATE, AudioFormat.CHANNEL_IN_STEREO, AudioFormat.ENCODING_PCM_16BIT);
//使用 AudioRecord 去录音
record = new AudioRecord(
        MediaRecorder.AudioSource.MIC,
        AUDIO_RATE,
        AudioFormat.CHANNEL_IN_STEREO,
        AudioFormat.ENCODING_PCM_16BIT,
        minBufferSize
);

上面通过 AudioRecord.getMinBufferSize() 来获取最小一帧的buffer 大小,这样我们能保证每一帧都能被录制。它的参数如下:

  • sampleRateInHz :采样率,上面说到,想要无损印制,至少 44100hz ,所以这里也是用 44100hz
  • channelConfig:声道,这里采用双声道
  • audioFormat:PCM 采样位数,一般现在的手机斗志16bit的,也足够使用了,所以这里也是使用 16 bit

接着创建 AudioRecord ,参数也不难理解,这里不再赘述。

怎么去录制呢?说白了,就是通过 AudioRecord 的read方法,它会把数据读写到 byte[] 数组中,然后返回写入的大小,根据 byte 就可以保存到文件中了,代码如下:

        @Override
        public void run() {
            super.run();
            FileOutputStream fos = null;

            try {
                //没有先创建文件夹
                File dir = new File(PATH);
                if (!dir.exists()) {
                    dir.mkdirs();
                }
                //创建 pcm 文件
                File pcmFile = getFile(PATH, "test.pcm");
                fos = new FileOutputStream(pcmFile);
                
                //开始录制
                record.startRecording();
                byte[] buffer = new byte[minBufferSize];
                while (!isDone) {
                    //读取数据
                    int read = record.read(buffer, 0, buffer.length);
                    if (AudioRecord.ERROR_INVALID_OPERATION != read) {
                        //写 pcm 数据
                        fos.write(buffer, 0, read);
                    }

                }
                //录制结束
                record.stop();
                record.release();
                fos.flush();

            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                CloseUtils.close(fos);
                record.release();
            }
        }

首先,使用 record.startRecording() 开始,此时它会开始监听硬件音频数据,然后通过 read() 方法读取数据,接着把它保存到文件中。

然后我们发现,已经保存了音频的原始数据 PCM 文件:

二、Wav 文件

上面只保存了 pcm 文件,但这是原始的 pcm 文件,它是不支持播放的。我们需要将它转换成 wav 这种可以被识别解码的音频格式。

想要把 pcm 格式转换成 wav,只需要在pcm的文件起始位置加上至少44个字节的WAV头信息即可

这个文件头记录着音频流的编码参数。数据块的记录方式是little-endian字节顺序,来一张官方图:

关于 wav 的说明,这里不重点介绍,它的头部生成方法如下:

/**
     * 任何一种文件在头部添加相应的头文件才能够确定的表示这种文件的格式,
     * wave是RIFF文件结构,每一部分为一个chunk,其中有RIFF WAVE chunk,
     * FMT Chunk,Fact chunk,Data chunk,其中Fact chunk是可以选择的
     *
     * @param pcmAudioByteCount 不包括header的音频数据总长度
     * @param longSampleRate    采样率,也就是录制时使用的频率
     * @param channels          audioRecord的频道数量
     */

    private byte[] generateWavFileHeader(long pcmAudioByteCount, long longSampleRate, int channels) {
        long totalDataLen = pcmAudioByteCount + 36// 不包含前8个字节的WAV文件总长度
        long byteRate = longSampleRate * 2 * channels;
        byte[] header = new byte[44];
        header[0] = 'R'// RIFF
        header[1] = 'I';
        header[2] = 'F';
        header[3] = 'F';

        header[4] = (byte) (totalDataLen & 0xff);//数据大小
        header[5] = (byte) ((totalDataLen >> 8) & 0xff);
        header[6] = (byte) ((totalDataLen >> 16) & 0xff);
        header[7] = (byte) ((totalDataLen >> 24) & 0xff);

        header[8] = 'W';//WAVE
        header[9] = 'A';
        header[10] = 'V';
        header[11] = 'E';
        //FMT Chunk
        header[12] = 'f'// 'fmt '
        header[13] = 'm';
        header[14] = 't';
        header[15] = ' ';//过渡字节
        //数据大小
        header[16] = 16// 4 bytes: size of 'fmt ' chunk
        header[17] = 0;
        header[18] = 0;
        header[19] = 0;
        //编码方式 10H为PCM编码格式
        header[20] = 1// format = 1
        header[21] = 0;
        //通道数
        header[22] = (byte) channels;
        header[23] = 0;
        //采样率,每个通道的播放速度
        header[24] = (byte) (longSampleRate & 0xff);
        header[25] = (byte) ((longSampleRate >> 8) & 0xff);
        header[26] = (byte) ((longSampleRate >> 16) & 0xff);
        header[27] = (byte) ((longSampleRate >> 24) & 0xff);
        //音频数据传送速率,采样率*通道数*采样深度/8
        header[28] = (byte) (byteRate & 0xff);
        header[29] = (byte) ((byteRate >> 8) & 0xff);
        header[30] = (byte) ((byteRate >> 16) & 0xff);
        header[31] = (byte) ((byteRate >> 24) & 0xff);
        // 确定系统一次要处理多少个这样字节的数据,确定缓冲区,通道数*采样位数
        header[32] = (byte) (2 * channels);
        header[33] = 0;
        //每个样本的数据位数
        header[34] = 16;
        header[35] = 0;
        //Data chunk
        header[36] = 'd';//data
        header[37] = 'a';
        header[38] = 't';
        header[39] = 'a';
        header[40] = (byte) (pcmAudioByteCount & 0xff);
        header[41] = (byte) ((pcmAudioByteCount >> 8) & 0xff);
        header[42] = (byte) ((pcmAudioByteCount >> 16) & 0xff);
        header[43] = (byte) ((pcmAudioByteCount >> 24) & 0xff);
        return header;
    }

在上面的方法中,通过先后才能下载了 pcm 文件;为了方便,我们可以在 下载 pcm 之前,把头部信息先存储起来,接着再填充 pcm 文件,完成代码如下:

        @Override
        public void run() {
            super.run();
            FileOutputStream fos = null;
            FileOutputStream wavFos = null;
            RandomAccessFile wavRaf = null;
            try {
                //没有先创建文件夹
                File dir = new File(PATH);
                if (!dir.exists()) {
                    dir.mkdirs();
                }
                //创建 pcm 文件
                File pcmFile = getFile(PATH, "test.pcm");
                //创建 wav 文件
                File wavFile = getFile(PATH, "test.wav");
                fos = new FileOutputStream(pcmFile);
                wavFos = new FileOutputStream(wavFile);

                //先写头部,刚才是,我们并不知道 pcm 文件的大小
                byte[] headers = generateWavFileHeader(0, AUDIO_RATE, record.getChannelCount());
                wavFos.write(headers, 0, headers.length);

                //开始录制
                record.startRecording();
                byte[] buffer = new byte[minBufferSize];
                while (!isDone) {
                    //读取数据
                    int read = record.read(buffer, 0, buffer.length);
                    if (AudioRecord.ERROR_INVALID_OPERATION != read) {
                        //写 pcm 数据
                        fos.write(buffer, 0, read);
                        //写 wav 格式数据
                        wavFos.write(buffer, 0, read);
                    }

                }
                //录制结束
                record.stop();
                record.release();
                
                fos.flush();
                wavFos.flush();

                //修改头部的 pcm文件 大小
                wavRaf = new RandomAccessFile(wavFile, "rw");
                byte[] header = generateWavFileHeader(pcmFile.length(), AUDIO_RATE, record.getChannelCount());
                wavRaf.seek(0);
                wavRaf.write(header);

            } catch (IOException e) {
                e.printStackTrace();
                Log.d(TAG, "zsr run: " + e.getMessage());
            } finally {
                CloseUtils.close(fos, wavFos,wavRaf);
            }
        }

        public void done() {
            interrupt();
            isDone = true;
        }
    });

可以看到,我们先新建了一个test.wav 的文件,先写入 头部信息,由于无法确定pcm的大小,先传入0,接着再把 pcm 写入到 wav 文件中,当录制结束,再把 pcm 的文件大小写入header头部即可。

当点击 test.wav 就可以播放啦,就可以听到你自己的骚声音了。

2.1 播放 wav 文件

这里使用Android自带的播放器就可以了,当然你也可以使用 MediaPlayer,使用Android 自带的如下:

    public void playwav(View view) {

        File file = new File(PATH, "test.wav");
        if (file.exists()) {

            Intent intent = new Intent();
            intent.setAction(Intent.ACTION_VIEW);
            Uri uri;
            //Android 7.0 以上,需要使用 FileProvider 
            if (Build.VERSION.SDK_INT > Build.VERSION_CODES.N) {
                uri = FileProvider.getUriForFile(this"com.zhengsr.videodemo.fileprovider", file);
            } else {
                uri = Uri.fromFile(file.getAbsoluteFile());
            }
            intent.setDataAndType(uri, "audio");
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);

            startActivity(intent);
        } else {
            Toast.makeText(this"请先录制", Toast.LENGTH_SHORT).show();
        }
    }

注意,如果是7.0 及以上,不能使用显性的 Uri了,所以需要使用FileProvider,它其实也是一个 contentprovider,记得在 AndroidMinefest 也写上:

新建一个 xml,添加一个 file_paths.xml 文件:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
    <external-path
        name="com.zhengsr.videodemo.fileprovider"
        path="/"/>

</paths>

三. 播放 PCM 音频

上面我们通过转换 PCM 为 WAV ,使其变成能够被多媒体解码识别的文件,但如果我想播放 pcm 文件呢?

这里可以通过 AudioTrack 来实现该功能,它为 Android 管理和播放音频的管理类,允许 PCM 音频通过write() 方法将数据流推送到 AudioTrack 来实现音频的播放。(当然也不局限 pcm,其他音频格式也支持的)

AudioTrack 有两种模式:流模式和静态模式

流模式:在流模式,当使用 write() 方法时,会向 AudioTrack 写入连续的数据流,数据会从 Java 层传输到底层,并排队阻塞等待播放;在播放音频块数据时,流模式比较好用:

  • 音频数据过大过长,无法存入内存时
  • 由于音频数据的特性(高采样率,每采样位…),太大而无法装入内存。
  • 接收或生成时,先前排队的音频正在播放。

静态模式:静态模式,它需要一次性把数据写到buffer中,适合小音频,小延迟的音频播放,常用在UI和游戏中比较实用。

这里,我们粉笔用两种模式去读取刚才的录音。

3.1 静态模式

上面说到,静态模式下,需要一次性把音频数据写到buffer中,所以这个 buffer 肯定不能太大,不过我们刚才的录音不算大,所以可以拿到上面录制的 pcm 来实践。

    public void playpcm2(View view) {
        try {
            File file = new File(PATH, "test.pcm");
            InputStream is = new FileInputStream(file);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int len;
            //创建一个数组
            byte[] buffer = new byte[1024];
            while ((len = is.read(buffer)) > 0) {
                //把数据存到ByteArrayOutputStream中
                baos.write(buffer, 0, len);
            }
            //拿到音频数据
            byte[] bytes = baos.toByteArray();

            //双声道
            int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
            /**
             * 设置音频信息属性
             * 1.设置支持多媒体属性,比如audio,video
             * 2.设置音频格式,比如 music
             */

            AudioAttributes attributes = new AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                    .build();
            /**
             * 设置音频哥特式
             * 1. 设置采样率
             * 2. 设置采样位数
             * 3. 设置声道
             */

            AudioFormat format = new AudioFormat.Builder()
                    .setSampleRate(AUDIO_RATE)
                    .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                    .setChannelMask(channelConfig)
                    .build();
            //注意 bufferSizeInBytes 使用音频的大小
            AudioTrack audioTrack = new AudioTrack(
                    attributes,
                    format,
                    bytes.length,
                    AudioTrack.MODE_STATIC, //设置为静态模式
                    AudioManager.AUDIO_SESSION_ID_GENERATE //音频识别id
            );
            //一次性写入
            audioTrack.write(bytes, 0, bytes.length);
            //开始播放
            audioTrack.play();
          
        } catch (Exception e) {
            e.printStackTrace();
            Log.d(TAG, "zsr playpcm2: " + e);
        }

    }

注释都比较清晰了,先把 test.pcm 文件的数据取出来,放到 ByteArrayOutputStream,然后再通过 audioTrack.write() 写入到 audiotrack中,点击播放即可。

3.2 流模式

流模式,数据会从 Java 层传输到底层,并排队阻塞等待播放,所以,这里我们开启一个线程,读取数据后等待播放,初始化与 static 模式没啥区别:

        public AudioTrackThread() {

            int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
           
            /**
             * 设置音频信息属性
             * 1.设置支持多媒体属性,比如audio,video
             * 2.设置音频格式,比如 music
             */

            AudioAttributes attributes = new AudioAttributes.Builder()
                    .setUsage(AudioAttributes.USAGE_MEDIA)
                    .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
                    .build();
            /**
             * 设置音频哥特式
             * 1. 设置采样率
             * 2. 设置采样位数
             * 3. 设置声道
             */

            AudioFormat format = new AudioFormat.Builder()
                    .setSampleRate(AUDIO_RATE)
                    .setEncoding(AudioFormat.ENCODING_PCM_16BIT)
                    .setChannelMask(channelConfig)
                    .build();
            //拿到一帧的最小buffer大小
            bufferSize = AudioTrack.getMinBufferSize(AUDIO_RATE, channelConfig, AudioFormat.ENCODING_PCM_16BIT);
            audioTrack = new AudioTrack(
                    attributes,
                    format,
                    bufferSize,
                    AudioTrack.MODE_STREAM,
                    AudioManager.AUDIO_SESSION_ID_GENERATE
            );
   //播放,等待数据
            audioTrack.play();

        }

就是初始化 AudioTrack 时,由于是流模式,所以大小只需要设置一帧的最小buffer 即可,然后调用 play() 方法去等待数据,当AudioTrack 的 write() 有数据到来时,就会播放音频:

        @Override
        public void run() {
            super.run();
            File file = new File(PATH, "test.pcm");
            if (file.exists()) {
                FileInputStream fis = null;
                try {

                    fis = new FileInputStream(file);
                    byte[] buffer = new byte[bufferSize];
                    int len;
                    while (!isDone && (len = fis.read(buffer)) > 0) {
                     // 写数据到 AudioTrack中,等到播放
                        audioTrack.write(buffer, 0, len);
                    }

                    audioTrack.stop();
                    audioTrack.release();

                } catch (Exception e) {
                    e.printStackTrace();
                    Log.d(TAG, "zsr run: " + e);
                } finally {
                    CloseUtils.close(fis);
                }

            }

        }

这样,关于 AudioRecord 和 AudioTrack 就学习完啦。

参考:

  • https://developer.android.google.cn/reference/kotlin/android/media/AudioTrack?hl=en
  • https://www.jianshu.com/p/1749d2d43ecb


---END---


推荐阅读:
Android10.0(Q) Launcher3 去掉抽屉效果!
都说副业刚需,我整理了19个私活平台给你!
Kotlin 1.4 正式版发布,专注于质量和性能!
最全面的44个Java 性能调优细节!
这个项目太屌了吧!(续)
这个项目太屌了吧!
H5秒开方案思考与实践
在 View 上使用挂起函数


更文不易,点个“在看”支持一下👇

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存